主题
Go 基准测试:让“更快了”有可以复核的含义
把一次 ns/op 变小叫优化,容易把机器噪声、输入变化甚至编译器删掉代码误认成收益。一份可靠的微基准至少要说清:测了什么操作,输入是否相同,结果有没有被算出来,前后各跑了多少次,以及变化有多大。
本文把同一个字符串拼接函数编译成两个版本,交错运行 12 组,再用固定版本 benchstat 做比较。除此之外,还运行一个故意写错的基准,看编译器如何把被测乘法全部消掉。环境是 Go 1.27.2、Linux/amd64;这些数字只描述本地实验,不外推服务器容量。
从一个“操作”开始,而不是从计时器开始
例子的操作定义是:将 128 个 "go中" 拼成一个字符串,共 640 字节。旧实现循环 +=,新实现用 strings.Builder。输入准备不计时,输出分配与拼接计时。两版使用同一测试验证空输入、空字符串、中文和普通 ASCII 的结果等于 strings.Join(parts, "")。
go
func BenchmarkBuild(b *testing.B) {
parts := make([]string, 128)
for i := range parts { parts[i] = "go中" }
b.ReportAllocs()
b.SetBytes(int64(len(parts) * len(parts[0])))
for b.Loop() {
_ = Build(parts)
}
}BenchmarkXxx(*testing.B) 放在 _test.go 文件里。go test -bench 才会运行基准;-run='^$' 跳过普通测试,适合在已经独立验证正确性后做计时。b.Loop 第一次调用时重置计时,返回 false 时停止计时,因此循环前的准备和循环后的清理默认不计入。[1][2]
一次原始输出如下:
text
BenchmarkBuild 14857 7856 ns/op 81.47 MB/s 43208 B/op 127 allocs/op| 字段 | 本次含义 | 不代表什么 |
|---|---|---|
14857 | 测量过程中完成的操作次数 | 不是样本次数,也不是并发数 |
7856 ns/op | 测量耗时除以操作次数 | 不是某个请求的 p99 |
81.47 MB/s | 根据 SetBytes(640) 换算的吞吐 | 不是网卡或磁盘真实吞吐 |
43208 B/op | 每次操作平均分配的堆字节数 | 不是最终字符串大小,也不是存活内存 |
127 allocs/op | 每次操作平均堆分配次数 | 不包含所有栈上临时变量 |
原始基准有上万次操作,但在 benchstat 里这一整行只是一份测量样本。不要把一行中的循环次数当作“我已经测了上万组”。
testing.B 的常用动作
| 要解决的问题 | 入口 | 注意事项 |
|---|---|---|
| 让框架决定迭代次数 | for b.Loop() | Go 1.24 加入;不要再嵌套一层循环到 b.N |
| 显示分配 | -benchmem 或 b.ReportAllocs() | 本例两者都验证过;观察的是分配,不是 RSS |
| 避免把准备、复位计时 | ResetTimer、StopTimer、StartTimer | 明确哪些工作生产中真要做;频繁停表本身也扰动实验 |
| 比较输入规模 | b.Run("size=...", fn) | 子基准名字保留尺寸、实现等变量,不把不同工作量合并 |
| 测并发吞吐 | b.RunParallel,循环用 pb.Next() | 工作分配到多 goroutine;总墙钟时间/总操作数不是单请求延迟 |
| 报告额外指标 | b.ReportMetric(value, "items/op") | 单位与分母要说清,别拿自定义指标冒充框架统计 |
| 固定测量预算 | -benchtime=100ms 或 -benchtime=10000x | 时间预算与次数预算含义不同;极短测量容易受噪声影响 |
| 多次测量 | -count=12 | 同一批后跑完所有新版本,仍可能受系统漂移影响 |
| 指定并行度 | -cpu=1,2,4 | 它设置不同 GOMAXPROCS;每一种配置分别比较 |
这些入口的行为以当前 testing 和 go help testflag 为准。尤其 B.Loop 的防消除实现已变化:Go 1.27.2 文档描述编译器对循环内相应变量加入 runtime.KeepAlive intrinsic,而不是把所有函数调用都禁用内联。别从旧版本博客抄来“永远不能内联”的结论。[1]
基准能通过,也可能什么都没测
下面的基准是故意写错的反例:
go
func square(x uint64) uint64 { return x * x }
func BenchmarkElided(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = square(uint64(i))
}
}结果没有可观察用途,编译器内联后能删掉乘法。本次 3 次测量是 0.3017、0.2945、0.3055 ns/op。单看数字不能证明消除,所以又检查了生成的机器码:
text
XORL CX, CX
JMP ...
INCQ CX
CMPQ ...
JG ...
RET完整 go tool objdump 输出确实只有循环控制,没有乘法,也没有 square 调用。这个反例测到的主要是空循环。不能把它当作“Go 乘法只需 0.3 ns”的依据。
在当前版本中,循环条件必须写成准确的 for b.Loop() { ... } 形式,编译器才会对该循环体内符合条件的调用参数、结果和赋值变量保持活跃。示例里的 _ = Build(parts) 因而不会把整个调用当成无用结果删除。保护不意味着所有优化都关闭:常量折叠、算法简化和正常内联仍需结合实际代码理解。[1]
文末另有 BenchmarkKept 让累积变量在 b.Loop 中变化,用作防消除写法示范。它与错误版本的输入依赖不同,两个数字不能用来计算“防消除导致慢几倍”。如果必须维护旧式 b.N 基准,可让结果在循环后被明确使用;全局 sink 可能改变逃逸与缓存行为,仍应检查是不是测成了另一件事。
前后比较,先把其他变量固定
这次两个版本由 build tag 选择,函数名、测试、输入和基准完全相同。先各运行正确性测试、race 和 vet,再生成普通优化构建的测试二进制。race 构建只查竞态,从不拿它的时间与普通构建比较。
正在绘制图示…
查看图源代码
flowchart LR
A[同一输入与同一基准] --> B[base.test]
A --> C[optimized.test]
B --> D[12 组交错测量<br/>同工具链 / GOMAXPROCS=1]
C --> D
D --> E[保留全部原始行]
E --> F[benchstat<br/>效应大小与不确定性]
F --> G[代表性负载复测<br/>延迟 / CPU / 内存 / 正确性]
固定记录如下:
| 条件 | 本次值 |
|---|---|
| Go / OS / 架构 | Go 1.27.2,Linux 6.18.44,amd64 |
| CPU 标识 | AMD EPYC 9V74 80-Core Processor |
| 调度配置 | GOMAXPROCS=1,-test.cpu=1 |
| 每份样本预算 | -test.benchtime=100ms |
| 样本数 | 前后各 12;奇数组先 base,偶数组先 optimized |
| 构建 | 标准优化,不加 -race,不加 -N -l |
| 环境限制 | 共享云机器,不能证明其他进程静止、物理频率恒定或 CPU 独占 |
| benchstat | golang.org/x/perf 固定到 v0.0.0-20261009192801-be2c69fb417e |
交错运行降低“旧版先跑时机器较空、新版后跑时机器较忙”的系统性偏差,不能消灭所有噪声。读者要复测自己的输入规模和并发条件;拼接 2 个字符串和拼接 128 个字符串,不一定值得同一种改法。[3]
benchstat 输出究竟允许说到哪一步
本次真实结果:
| 指标 | base 中位数 | optimized 中位数 | benchstat 报告 |
|---|---|---|---|
| 耗时 | 8107.5 ns/op,±7% | 800.4 ns/op,±13% | -90.13%,p=0.000,n=12 |
| 分配字节 | 42.195 KiB/op,±0% | 1.867 KiB/op,±0% | -95.57%,p=0.000,n=12 |
| 分配次数 | 127/op,±0% | 8/op,±0% | -93.70%,p=0.000,n=12 |
± 是工具给中位数的置信区间摘要,不是单次请求落入的范围。该版 benchstat 默认使用非参数统计,中位数作摘要,Mann–Whitney U 检验做 A/B 比较;默认显著性阈值是 0.05。输出 p=0.000 是格式化结果,不表示概率数学上为零,也不是“优化正确的概率是 100%”。[3]
统计结论仍依赖实验设计:样本应来自可比的工作量,环境偏差不应系统性偏向某个版本。U 检验比较分布,只有在相应形状、位置差异等解释条件下,才适合进一步把结果说成典型耗时的位置变化。此处差异大,分配计数也一致支持“在这个输入上 Builder 少制造了很多中间字符串”;并不证明所有服务会快 90%。
若输出 ~,含义是工具未检出显著差异,不等于证明完全相同。若有差异但只有 0.5%,也要看这条路径在总成本里占多少、代码复杂度增加多少。事先确定样本数,不要反复运行直到 p<0.05 再挑那一次贴出来;一次对比很多指标或基准,还要考虑多重比较带来的假阳性。[3]
微基准通过之后,把优化放回代表性负载:关注吞吐、p95/p99、CPU、存活堆、GC、锁等待与错误率。有时每次操作更省内存,却把大缓冲留在池里,进程常驻内存反而上涨;有时批量吞吐更好,却让第一条请求等更久。微基准负责回答一个小问题,系统验证负责判断改动是否值得上线。
依据
- Go 1.27.2 testing,尤其
B.Loop、RunParallel、SetBytes、ReportAllocs;本机同版本go doc与api/go1.24.txt交叉核对。 - Go 1.27.2 cmd/go:测试与基准参数。
- benchstat 官方文档;固定源码提交 be2c69fb417e:中位数、置信区间、默认检验、交错运行与多重比较提示。本次工具模块校验和
h1:IB9P+pA897fozv0n9ldn+jTDNWtEMfbomaXXa4dKr74=。 - Go 1.27.2 strings.Builder:拼接 API;性能收益仅取本文实测。
完整复现代码、命令与原始测量
使用 Go 1.27.2,把三个 Go 文件、go.mod 与脚本保存在同一目录。bash run.sh out 生成两个测试二进制与原始结果。再用下面命令把 benchstat 安装到本目录,避免全局安装:
bash
mkdir -p bin
GOBIN="$PWD/bin" GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org \
go install golang.org/x/perf/cmd/benchstat@v0.0.0-20261009192801-be2c69fb417e
./bin/benchstat out/base.txt out/optimized.txt该工具的固定传递依赖为 github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794,由模块依赖和校验数据库验证。被测模块本身没有第三方依赖。
go.mod
go.mod
module example.com/benchlab
go 1.27.2build_base.go
go
//go:build !optimized
package benchlab
func Build(parts []string) string {
var out string
for _, p := range parts {
out += p
}
return out
}build_opt.go
go
//go:build optimized
package benchlab
import "strings"
func Build(parts []string) string {
var b strings.Builder
for _, p := range parts {
b.WriteString(p)
}
return b.String()
}build_test.go
代码块 · 37 行 · 点击收起 / 展开
go
package benchlab
import (
"strings"
"testing"
)
func TestBuild(t *testing.T) {
for _, parts := range [][]string{nil, {""}, {"a", "中", "42"}, {"a", "b", "c"}} {
if got, want := Build(parts), strings.Join(parts, ""); got != want {
t.Fatalf("got %q want %q", got, want)
}
}
}
func BenchmarkBuild(b *testing.B) {
parts := make([]string, 128)
for i := range parts {
parts[i] = "go中"
}
b.ReportAllocs()
b.SetBytes(int64(len(parts) * len(parts[0])))
for b.Loop() {
_ = Build(parts)
}
}
func square(x uint64) uint64 { return x * x }
func BenchmarkElided(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = square(uint64(i))
}
}
func BenchmarkKept(b *testing.B) {
x := uint64(17)
for b.Loop() {
x = square(x + 1)
}
}run.sh
bash
#!/usr/bin/env bash
set -euo pipefail
export TZ=UTC
out=${1:-out}
mkdir -p "$out"
{ go version; go env GOOS GOARCH CGO_ENABLED GOEXPERIMENT; uname -sr; grep -m1 'model name' /proc/cpuinfo; } > "$out/environment.txt"
for tag in '' optimized; do
go test -tags="$tag" .
go test -race -tags="$tag" .
go vet -tags="$tag" .
done > "$out/test.txt"
go test -c -o "$out/base.test" .
go test -c -tags=optimized -o "$out/optimized.test" .
: > "$out/base.txt"
: > "$out/optimized.txt"
for i in $(seq 1 12); do
if ((i % 2)); then order='base optimized'; else order='optimized base'; fi
for name in $order; do
GOMAXPROCS=1 "$out/$name.test" -test.run='^$' -test.bench='^BenchmarkBuild$' -test.benchtime=100ms -test.cpu=1 >> "$out/$name.txt"
done
done
GOMAXPROCS=1 "$out/base.test" -test.run='^$' -test.bench='Benchmark(Elided|Kept)$' -test.benchtime=100ms -test.count=3 -test.cpu=1 > "$out/elision.txt"
go tool objdump -s 'example.com/benchlab.BenchmarkElided' "$out/base.test" > "$out/elided-assembly.txt"
go test -run='^$' -bench='^BenchmarkBuild$' -benchmem -benchtime=100ms -cpu=1 > "$out/benchmem.txt"本次所有拼接测量行
以下只移除了重复的机器标识与 PASS 行,没有删选样本。前 12 行是 base,后 12 行是 optimized。
text
[base]
BenchmarkBuild 14857 7856 ns/op 81.47 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 13351 9175 ns/op 69.75 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 13530 8065 ns/op 79.35 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 15705 7246 ns/op 88.32 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 15253 7704 ns/op 83.08 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 13712 8505 ns/op 75.25 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 14859 7800 ns/op 82.05 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 15031 8150 ns/op 78.52 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 13634 8646 ns/op 74.02 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 15294 7598 ns/op 84.23 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 12559 9156 ns/op 69.90 MB/s 43208 B/op 127 allocs/op
BenchmarkBuild 12865 8419 ns/op 76.01 MB/s 43208 B/op 127 allocs/op
[optimized]
BenchmarkBuild 149546 799.2 ns/op 800.78 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 153391 798.8 ns/op 801.18 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 156236 760.9 ns/op 841.12 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 135510 836.0 ns/op 765.58 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 137412 801.5 ns/op 798.52 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 158506 757.1 ns/op 845.36 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 127860 822.0 ns/op 778.56 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 160852 743.4 ns/op 860.89 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 130710 904.0 ns/op 708.00 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 151222 781.7 ns/op 818.75 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 107434 982.6 ns/op 651.31 MB/s 1912 B/op 8 allocs/op
BenchmarkBuild 122094 939.9 ns/op 680.92 MB/s 1912 B/op 8 allocs/op